JS: Introduce helper functions for event listeners#5495
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughReplaces manual DOM event listener wiring in JS/WASM client engines and WebSocket sessions with typed Changes
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
0666f3b to
f7a5d01
Compare
|
Good call 👍 |
5a9cf5a to
96416ea
Compare
| // The callback parameter is JsAny, not Event, by design. | ||
| // Kotlin/WasmJS generates a JS adapter for each lambda based on its declared parameter type: | ||
| // a `(Event) -> Unit` adapter checks `x instanceof globalThis.Event` before invoking the lambda. | ||
| // The `ws` npm package defines its own `Event` hierarchy that does not extend `globalThis.Event` | ||
| // (see https://github.com/websockets/ws/issues/1818), so that check fails for every event fired | ||
| // by a Node.js WebSocket. | ||
| // Using `(JsAny) -> Unit` produces an adapter with no instanceof check, accepting any JS value. | ||
| // This is valid Kotlin: `(JsAny) -> Unit` is a subtype of `(Event) -> Unit` by contravariance. | ||
| // `unsafeCast<T>()` inside the body is a no-op — it changes only the compile-time type with no | ||
| // runtime check — so it never fails regardless of the actual JS object type. | ||
| @Suppress("RemoveExplicitTypeArguments") // Compiler fails to infer type arguments otherwise | ||
| val callback = { event: JsAny -> listener(event.unsafeCast<T>()) } |
There was a problem hiding this comment.
It turned out to be not so simple 😅
But at least now this code is isolated and documented here
There was a problem hiding this comment.
Have you tried this with the "once" option? https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#once
There was a problem hiding this comment.
Thanks! This is much better for event listeners listening a single event. I will try.
However, for the same listener registered to listen two events we will still need this utility function
There was a problem hiding this comment.
Ah yeah, that's a good point. You'd think this would be part of the JS std lib by now.
There was a problem hiding this comment.
Updated the implementation to use once for single-event listeners and our custom mechanism for multi-event listeners
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 96416ea030
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
96416ea to
0d74c0e
Compare
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In
`@ktor-client/ktor-client-core/wasmJs/src/io/ktor/client/plugins/websocket/JsWebSocketSession.kt`:
- Around line 97-109: The error handler was mistakenly registered for the
"close" event so clean closes are treated as errors; in JsWebSocketSession
change the onError listener to listen for the "error" event (use the appropriate
Event/ErrorEvent type instead of CloseEvent) and keep its logic to create the
WebSocketException and call _closeReason.completeExceptionally(cause),
_incoming.close(cause) and _outgoing.cancel(); leave the existing one-time
"close" listener (which completes _closeReason normally, sends Frame.Close and
closes streams) unchanged so normal closes win.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 7557e285-ea60-4057-8716-15d7bbd2a830
📒 Files selected for processing (5)
ktor-client/ktor-client-core/js/src/io/ktor/client/engine/js/JsClientEngine.ktktor-client/ktor-client-core/js/src/io/ktor/client/plugins/websocket/JsWebSocketSession.ktktor-client/ktor-client-core/wasmJs/src/io/ktor/client/engine/js/WasmJsClientEngine.ktktor-client/ktor-client-core/wasmJs/src/io/ktor/client/plugins/websocket/JsWebSocketSession.ktktor-client/ktor-client-core/web/src/io/ktor/client/utils/JsUtils.web.kt
There was a problem hiding this comment.
🧹 Nitpick comments (1)
ktor-client/ktor-client-core/web/src/io/ktor/client/utils/JsUtils.web.kt (1)
54-64: Potential race when multiple events fire in the same microtask.When
addOneTimeEventListeneris called with multiple event names (e.g.,"open", "error"), if both events fire beforedispose()finishes removing all listeners, the callback could execute twice. JavaScript's single-threaded model doesn't fully protect against this since both events could be queued and processed sequentially before the synchronousdispose()loop completes for all event names.In practice, for the current usage (
awaitConnectionwith"open"/"error"), a WebSocket typically fires only one of these events, so this may be acceptable. However, the KDoc should document this limitation, or consider adding a guard (e.g., anAtomicBooleanor captured flag) to ensure single invocation.♻️ Suggested documentation or guarded implementation
Option 1: Document the limitation in KDoc
/** * Registers a one-time event listener that removes itself after the first event fires. * The listener is registered for all specified [events] and fires once for whichever event occurs first. + * + * Note: If multiple registered events fire in quick succession before [dispose] completes, + * the [listener] may be invoked more than once. Callers relying on single invocation should + * ensure the underlying event source only fires one of the registered events. * * The event is unsafe-cast to [T] before invoking [listener]. See [addEventListener] for why.Option 2: Add a guard for guaranteed single invocation
internal inline fun <reified T : Event> EventTarget.addOneTimeEventListener( vararg events: String, crossinline listener: (T) -> Unit ): DisposableHandle { + var fired = false lateinit var disposable: DisposableHandle disposable = addEventListener<T>(*events) { event -> + if (fired) return@addEventListener + fired = true disposable.dispose() listener(event) } return disposable }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@ktor-client/ktor-client-core/web/src/io/ktor/client/utils/JsUtils.web.kt` around lines 54 - 64, The addOneTimeEventListener implementation for EventTarget can invoke the listener twice if multiple events fire before dispose removes all listeners; update EventTarget.addOneTimeEventListener to capture a local guard flag (e.g., var invoked = false) closed over by the returned listener so the first invocation atomically checks and sets invoked (if already true, return), then calls disposable.dispose() and listener(event); also add a short KDoc note on EventTarget.addOneTimeEventListener explaining the single-invocation guarantee. Ensure references to DisposableHandle, addEventListener and EventTarget.addOneTimeEventListener are used when making the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@ktor-client/ktor-client-core/web/src/io/ktor/client/utils/JsUtils.web.kt`:
- Around line 54-64: The addOneTimeEventListener implementation for EventTarget
can invoke the listener twice if multiple events fire before dispose removes all
listeners; update EventTarget.addOneTimeEventListener to capture a local guard
flag (e.g., var invoked = false) closed over by the returned listener so the
first invocation atomically checks and sets invoked (if already true, return),
then calls disposable.dispose() and listener(event); also add a short KDoc note
on EventTarget.addOneTimeEventListener explaining the single-invocation
guarantee. Ensure references to DisposableHandle, addEventListener and
EventTarget.addOneTimeEventListener are used when making the change.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 0eddc158-72c4-4071-8952-a1c16e623fd2
📒 Files selected for processing (5)
ktor-client/ktor-client-core/js/src/io/ktor/client/engine/js/JsClientEngine.ktktor-client/ktor-client-core/js/src/io/ktor/client/plugins/websocket/JsWebSocketSession.ktktor-client/ktor-client-core/wasmJs/src/io/ktor/client/engine/js/WasmJsClientEngine.ktktor-client/ktor-client-core/wasmJs/src/io/ktor/client/plugins/websocket/JsWebSocketSession.ktktor-client/ktor-client-core/web/src/io/ktor/client/utils/JsUtils.web.kt
🚧 Files skipped from review as they are similar to previous changes (1)
- ktor-client/ktor-client-core/js/src/io/ktor/client/engine/js/JsClientEngine.kt
c067e6b to
861faa1
Compare
There was a problem hiding this comment.
🧹 Nitpick comments (1)
ktor-client/ktor-client-core/wasmJs/src/io/ktor/client/plugins/websocket/JsWebSocketSession.kt (1)
97-102: Useevent.messagefor a more informative error message in WebSocket error events.Using
"$event"produces unhelpful output like[object ErrorEvent]. TheErrorEventtype has amessageproperty that provides actionable diagnostic information, as used in the corresponding engine error handlers.♻️ Suggested improvement
val onError = websocket.addEventListener<ErrorEvent>("error") { event -> - val cause = WebSocketException("$event") + val cause = WebSocketException(event.message) _closeReason.completeExceptionally(cause) _incoming.close(cause) _outgoing.cancel() }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@ktor-client/ktor-client-core/wasmJs/src/io/ktor/client/plugins/websocket/JsWebSocketSession.kt` around lines 97 - 102, The error handler attached as onError constructs a WebSocketException from "$event" which yields unhelpful "[object ErrorEvent]"; change the construction to use the ErrorEvent message (e.g., event.message, falling back to event.toString() if empty) when creating the WebSocketException in websocket.addEventListener, so _closeReason.completeExceptionally(WebSocketException(...)), _incoming.close(...), and _outgoing.cancel() carry the real diagnostic text; update references in the onError handler where WebSocketException is created to use event.message.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In
`@ktor-client/ktor-client-core/wasmJs/src/io/ktor/client/plugins/websocket/JsWebSocketSession.kt`:
- Around line 97-102: The error handler attached as onError constructs a
WebSocketException from "$event" which yields unhelpful "[object ErrorEvent]";
change the construction to use the ErrorEvent message (e.g., event.message,
falling back to event.toString() if empty) when creating the WebSocketException
in websocket.addEventListener, so
_closeReason.completeExceptionally(WebSocketException(...)),
_incoming.close(...), and _outgoing.cancel() carry the real diagnostic text;
update references in the onError handler where WebSocketException is created to
use event.message.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: bf59e66f-83da-483b-91ec-8aac499ababe
📒 Files selected for processing (5)
ktor-client/ktor-client-core/js/src/io/ktor/client/engine/js/JsClientEngine.ktktor-client/ktor-client-core/js/src/io/ktor/client/plugins/websocket/JsWebSocketSession.ktktor-client/ktor-client-core/wasmJs/src/io/ktor/client/engine/js/WasmJsClientEngine.ktktor-client/ktor-client-core/wasmJs/src/io/ktor/client/plugins/websocket/JsWebSocketSession.ktktor-client/ktor-client-core/web/src/io/ktor/client/utils/JsUtils.web.kt
🚧 Files skipped from review as they are similar to previous changes (2)
- ktor-client/ktor-client-core/web/src/io/ktor/client/utils/JsUtils.web.kt
- ktor-client/ktor-client-core/js/src/io/ktor/client/plugins/websocket/JsWebSocketSession.kt
861faa1 to
785825b
Compare
There was a problem hiding this comment.
🧹 Nitpick comments (2)
ktor-client/ktor-client-core/web/src/io/ktor/client/utils/JsUtils.web.kt (1)
57-57: Variable shadowing: consider renaming to avoid confusion.The parameter
eventsis shadowed by the localval events. While this works, it can be confusing during debugging or maintenance.♻️ Suggested rename
- val events = listOf(event, *events) + val allEvents = listOf(event, *events) lateinit var callback: (JsAny) -> Unit - val disposable = DisposableHandle { events.forEach { removeEventListener(it, callback) } } + val disposable = DisposableHandle { allEvents.forEach { removeEventListener(it, callback) } } callback = eventCallback<T> { if (once) disposable.dispose() listener(it) } - events.forEach { addEventListener(it, callback) } + allEvents.forEach { addEventListener(it, callback) }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@ktor-client/ktor-client-core/web/src/io/ktor/client/utils/JsUtils.web.kt` at line 57, The local declaration `val events = listOf(event, *events)` shadows the function parameter `events`; rename the local variable (e.g., `combinedEvents` or `eventsList`) and update all subsequent references in the same function to use that new name so the parameter and local list are not ambiguous; ensure any spread (`*events`) still references the original parameter and the rest of the logic (like iterating or passing the merged list) uses the renamed local variable.ktor-client/ktor-client-core/wasmJs/src/io/ktor/client/plugins/websocket/JsWebSocketSession.kt (1)
97-102: Consider using theeventAsStringpattern for safer error event handling.The
wsnpm package (used for Node.js WebSockets) may emit error events that don't have amessageproperty like the standard DOMErrorEventinterface. The code inWasmJsClientEngine.ktalready useseventAsString(event)to safely serialize the entire event object when handling errors during connection. Apply the same pattern here for consistency and robustness across the error handling code path.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@ktor-client/ktor-client-core/wasmJs/src/io/ktor/client/plugins/websocket/JsWebSocketSession.kt` around lines 97 - 102, The onError handler in JsWebSocketSession.kt constructs a WebSocketException from event.message which may be missing for non-DOM ErrorEvent instances; change it to use the same safe serialization used elsewhere (call eventAsString(event) to produce the error text) and pass that into WebSocketException, then continue to complete _closeReason exceptionally, close _incoming with the cause, and cancel _outgoing as before; reference the existing onError val, WebSocketException, _closeReason, _incoming, _outgoing, and the eventAsString utility used in WasmJsClientEngine.kt to locate where to update the handler.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In
`@ktor-client/ktor-client-core/wasmJs/src/io/ktor/client/plugins/websocket/JsWebSocketSession.kt`:
- Around line 97-102: The onError handler in JsWebSocketSession.kt constructs a
WebSocketException from event.message which may be missing for non-DOM
ErrorEvent instances; change it to use the same safe serialization used
elsewhere (call eventAsString(event) to produce the error text) and pass that
into WebSocketException, then continue to complete _closeReason exceptionally,
close _incoming with the cause, and cancel _outgoing as before; reference the
existing onError val, WebSocketException, _closeReason, _incoming, _outgoing,
and the eventAsString utility used in WasmJsClientEngine.kt to locate where to
update the handler.
In `@ktor-client/ktor-client-core/web/src/io/ktor/client/utils/JsUtils.web.kt`:
- Line 57: The local declaration `val events = listOf(event, *events)` shadows
the function parameter `events`; rename the local variable (e.g.,
`combinedEvents` or `eventsList`) and update all subsequent references in the
same function to use that new name so the parameter and local list are not
ambiguous; ensure any spread (`*events`) still references the original parameter
and the rest of the logic (like iterating or passing the merged list) uses the
renamed local variable.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 78a41874-e97a-4c64-8409-011a55e7308c
📒 Files selected for processing (5)
ktor-client/ktor-client-core/js/src/io/ktor/client/engine/js/JsClientEngine.ktktor-client/ktor-client-core/js/src/io/ktor/client/plugins/websocket/JsWebSocketSession.ktktor-client/ktor-client-core/wasmJs/src/io/ktor/client/engine/js/WasmJsClientEngine.ktktor-client/ktor-client-core/wasmJs/src/io/ktor/client/plugins/websocket/JsWebSocketSession.ktktor-client/ktor-client-core/web/src/io/ktor/client/utils/JsUtils.web.kt
🚧 Files skipped from review as they are similar to previous changes (1)
- ktor-client/ktor-client-core/js/src/io/ktor/client/engine/js/JsClientEngine.kt
785825b to
c27ff1d
Compare
There was a problem hiding this comment.
🧹 Nitpick comments (1)
ktor-client/ktor-client-core/web/src/io/ktor/client/utils/JsUtils.web.kt (1)
57-57: Variable shadows parameter name.The local
val eventsshadows thevararg eventsparameter. While this works correctly, consider renaming for clarity.♻️ Suggested rename for clarity
): DisposableHandle { - val events = listOf(event, *events) + val allEvents = listOf(event, *events) lateinit var callback: (JsAny) -> Unit - val disposable = DisposableHandle { events.forEach { removeEventListener(it, callback) } } + val disposable = DisposableHandle { allEvents.forEach { removeEventListener(it, callback) } } callback = eventCallback<T> { if (once) disposable.dispose() listener(it) } - events.forEach { addEventListener(it, callback) } + allEvents.forEach { addEventListener(it, callback) } return disposable }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@ktor-client/ktor-client-core/web/src/io/ktor/client/utils/JsUtils.web.kt` at line 57, The local declaration val events = listOf(event, *events) shadows the vararg parameter named events; rename the local variable (e.g., combinedEvents or eventsList) wherever used in the function to avoid shadowing and improve clarity (update the declaration and all subsequent references in the function such as usages of the newly named combinedEvents/eventsList instead of events).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@ktor-client/ktor-client-core/web/src/io/ktor/client/utils/JsUtils.web.kt`:
- Line 57: The local declaration val events = listOf(event, *events) shadows the
vararg parameter named events; rename the local variable (e.g., combinedEvents
or eventsList) wherever used in the function to avoid shadowing and improve
clarity (update the declaration and all subsequent references in the function
such as usages of the newly named combinedEvents/eventsList instead of events).
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 707e9e8e-00f5-4d23-a3fe-6db1e7861256
📒 Files selected for processing (7)
ktor-client/ktor-client-core/js/src/io/ktor/client/engine/js/JsClientEngine.ktktor-client/ktor-client-core/js/src/io/ktor/client/plugins/websocket/JsWebSocketSession.ktktor-client/ktor-client-core/js/src/io/ktor/client/utils/JsUtils.js.ktktor-client/ktor-client-core/wasmJs/src/io/ktor/client/engine/js/WasmJsClientEngine.ktktor-client/ktor-client-core/wasmJs/src/io/ktor/client/plugins/websocket/JsWebSocketSession.ktktor-client/ktor-client-core/wasmJs/src/io/ktor/client/utils/JsUtils.wasmJs.ktktor-client/ktor-client-core/web/src/io/ktor/client/utils/JsUtils.web.kt
Subsystem
JS/WasmJS client
Motivation
The existing approach was error-prone: it was easy to forget to call
removeEventListenerfor one-time listeners, and the only correct implementation required alateinit varto hold the callback reference so it could be removed inside itself.Solution
Introduces a type-safe wrapper for
addEventListenerin thewebsource set (shared betweenjsandwasmJs):DisposableHandleto remove the listener(s), eliminating the need to keep a separate lambda reference for cleanuponceparameter automatically removes the listener after the first event fires(JsAny) -> Unitinternally to bypass WasmJSinstanceofchecks that fail for non-standard event objects (e.g., from thewsnpm package)Refactors
JsClientEngine,WasmJsClientEngine, andJsWebSocketSession(bothjsandwasmJs) to use these helpers.